Skip to content

feat: Migrate public mail API to BullMQ for asynchronous delivery - #157

Merged
yash-pouranik merged 3 commits into
mainfrom
feat/mail-bullmq
May 8, 2026
Merged

feat: Migrate public mail API to BullMQ for asynchronous delivery#157
yash-pouranik merged 3 commits into
mainfrom
feat/mail-bullmq

Conversation

@yash-pouranik

@yash-pouranik yash-pouranik commented May 7, 2026

Copy link
Copy Markdown
Member

This PR migrates the public /api/mail/send endpoint to a Redis-backed BullMQ queue (publicEmailQueue). Previously, the API executed synchronous HTTP calls to Resend, which blocked the thread and caused users to hit 429 Too Many Requests when sending bulk emails concurrently (e.g., KBC Project bulk emails).

By offloading the execution to a background worker, the API now returns a fast 200 OK with a queue Job ID, while emails are processed steadily and securely in the background.

Key Changes:

  • Asynchronous Execution: Created publicEmailQueue to decouple the HTTP response from the email delivery.
  • Strict Rate Limiting: Enforced a 10 emails / second limiter on the worker to perfectly respect Resend's free-tier rate limits.
  • BYOK Support Maintained: Securely resolves custom API Keys inside the worker to ensure encryption tokens aren't stored in plaintext in the Redis queue.
  • Quota Drift Bugfix (P1): Added terminal-failure catch logic inside the worker (worker.on('failed')). If an email permanently fails to send, the consumed monthly mail quota is accurately refunded to the project via its consumedQuotaKey.
  • Retry Safety (P2): Guarded the quota refund to only execute when attemptsMade >= maxAttempts, preventing over-refunding drift on temporary network retries.

Testing & Verification (P3):

  • ✅ Refactored all apps/public-api/src/__tests__/mail.controller.test.js tests to mock BullMQ instead of Resend.
  • ✅ Added fully isolated tests for the worker's failed event listener to guarantee refunds on terminal failures.
  • ✅ Ensured non-terminal failures do not trigger quota rollbacks.
  • ✅ All 124 tests pass in band (npx jest --testPathPatterns=src/ --runInBand).

Type of Change:

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Performance Improvement

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works

Summary by CodeRabbit

  • New Features

    • Email sending now operates asynchronously through a queue for improved performance and reliability
    • App startup now initializes the public email worker so queued emails are processed automatically
    • Notification responses now indicate queued status and return a job identifier
  • Bug Fixes

    • Automatic quota restoration when email delivery fails permanently after all retries
  • Tests

    • Added tests covering template enqueueing and quota-refund behavior on worker failures

@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Rate limit exceeded

@Copilot has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 58 minutes and 16 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 680dcb10-3ec8-4131-8ba6-28d5d3ffb8c1

📥 Commits

Reviewing files that changed from the base of the PR and between 8b33857 and 5cb0cee.

📒 Files selected for processing (1)
  • packages/common/src/queues/publicEmailQueue.js
📝 Walkthrough

Hidden review stack artifact

Walkthrough

Adds a BullMQ-backed public email queue and worker (BYOK-capable), exports them from common, enqueues public emails from the mail controller, starts the worker at app boot, and updates tests to assert enqueue behavior and quota rollback on terminal failures.

Changes

Public Email Queue Implementation

Layer / File(s) Summary
Queue and Worker Definition
packages/common/src/queues/publicEmailQueue.js
Defines publicEmailQueue and initPublicEmailWorker() to process send-public-email jobs, selecting BYOK or environment Resend credentials, decrypting project API keys, and rolling back consumed quota on terminal failures.
Common Module Exports
packages/common/src/index.js
Exports publicEmailQueue and initPublicEmailWorker alongside existing auth and webhook queues.
Mail Controller Queue Integration
apps/public-api/src/controllers/mail.controller.js
Switches from synchronous resend.emails.send() to publicEmailQueue.add("send-public-email", ...), removing direct Resend client usage while preserving quota reservation and error handling; returns queued job id.
Worker Startup
apps/public-api/src/app.js
Initializes initPublicEmailWorker() during non-test application startup alongside webhook and auth email workers.
Test Coverage
apps/public-api/src/__tests__/mail.controller.test.js
Mocks publicEmailQueue.add(), asserts job enqueue with rendered template payload and attempt options, and adds tests verifying quota refund on terminal vs non-terminal worker failures.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • geturbackend/urBackend#84: Introduces BYOK support and worker initialization for auth emails; closely related to BYOK/worker patterns extended here.
  • geturbackend/urBackend#128: Modifies the mail sending path in the public API; related to controller and enqueue changes.

Poem

🐰 I hop through queues with carrot-bright cheer,
Jobs tuck in, waiting for Resend to steer—
Keys decrypt softly, templates take flight,
Failed attempts return quota at night,
A rabbit applauds this async delight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: migrating the public mail API to BullMQ for asynchronous delivery. It is specific, directly related to the changeset, and accurately represents the primary objective of the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mail-bullmq

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
apps/public-api/src/controllers/mail.controller.js (1)

282-295: ⚡ Quick win

Move key resolution fully to worker; avoid decrypting BYOK in controller path.

Now that sending is async, this pre-enqueue decrypt/key check is redundant and keeps secret handling on the API request path. Let the worker resolve credentials and keep the controller focused on validation + enqueue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/public-api/src/controllers/mail.controller.js` around lines 282 - 295,
The controller currently decrypts and validates a BYOK and selects a clientKey
(symbols: encryptedByokKey, decryptedByokKey, usingByok, clientKey, decrypt)
which moves secret handling into the request path; remove that logic so the
controller no longer calls decrypt or inspects/derives clientKey or returns a
500 when no env key is present. Instead, keep validation and enqueue the job
payload including project.resendApiKey (pass through the encrypted object or
null) and let the worker resolve/decrypt credentials and pick fallback env keys.
Ensure no decrypt(...) call or clientKey check remains in the controller.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/public-api/src/__tests__/mail.controller.test.js`:
- Around line 223-227: The test currently invokes failedHandler only if it
exists, allowing a false pass when the handler was never registered; add an
explicit assertion that failedHandler is defined before calling it (e.g.,
expect(failedHandler).toBeDefined()) so the test fails if the handler wasn't
wired, then proceed to call failedHandler(mockJob, new Error("Temporary
failure")) and assert mockRedis.decr was not called; reference the failedHandler
symbol and the mockRedis.decr expectation to locate where to insert the
assertion.

In `@packages/common/src/queues/publicEmailQueue.js`:
- Around line 53-59: The logs in the public email worker expose PII by printing
finalPayload.to; update the logging around the send call (the console.log before
calling resend.emails.send and the console.error in the catch path) to avoid raw
recipient addresses—either omit the address or log a masked version (e.g.,
replace the local part with asterisks or show only domain/first char) and
include contextual info like a message id or status; ensure you still throw the
Error as before but do not include the unmasked finalPayload.to in any log
output.
- Around line 80-84: The quota refund can drive Redis keys negative because
connection.decr on a missing key yields -1; change the logic around
job.data.consumedQuotaKey to only decrement when the stored value is > 0: either
(a) read the current value with connection.get(job.data.consumedQuotaKey),
parseInt it, and call connection.decr(...) only if the parsed value > 0 (and
optionally set to "0" if it's negative/NaN), or (b) replace the two-step
approach with an atomic Lua eval that decrements the key only if its current
value > 0 (use connection.eval with job.data.consumedQuotaKey to check and
decrement atomically); update the block containing job.opts?.attempts,
job.attemptsMade and connection.decr to use one of these guarded approaches.

---

Nitpick comments:
In `@apps/public-api/src/controllers/mail.controller.js`:
- Around line 282-295: The controller currently decrypts and validates a BYOK
and selects a clientKey (symbols: encryptedByokKey, decryptedByokKey, usingByok,
clientKey, decrypt) which moves secret handling into the request path; remove
that logic so the controller no longer calls decrypt or inspects/derives
clientKey or returns a 500 when no env key is present. Instead, keep validation
and enqueue the job payload including project.resendApiKey (pass through the
encrypted object or null) and let the worker resolve/decrypt credentials and
pick fallback env keys. Ensure no decrypt(...) call or clientKey check remains
in the controller.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 059013ac-3ad9-4f2c-ad98-229a3b18ba88

📥 Commits

Reviewing files that changed from the base of the PR and between a79dc10 and cebd009.

📒 Files selected for processing (5)
  • apps/public-api/src/__tests__/mail.controller.test.js
  • apps/public-api/src/app.js
  • apps/public-api/src/controllers/mail.controller.js
  • packages/common/src/index.js
  • packages/common/src/queues/publicEmailQueue.js

Comment on lines +223 to +227
if (failedHandler) {
await failedHandler(mockJob, new Error("Temporary failure"));
}

expect(mockRedis.decr).not.toHaveBeenCalled();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Non-terminal failure test can false-pass if handler is never wired.

Because invocation is guarded by if (failedHandler), this test passes when failedHandler is undefined. Assert handler registration first to make the test meaningful.

Suggested change
-        if (failedHandler) {
-            await failedHandler(mockJob, new Error("Temporary failure"));
-        }
+        expect(typeof failedHandler).toBe('function');
+        await failedHandler(mockJob, new Error("Temporary failure"));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (failedHandler) {
await failedHandler(mockJob, new Error("Temporary failure"));
}
expect(mockRedis.decr).not.toHaveBeenCalled();
expect(typeof failedHandler).toBe('function');
await failedHandler(mockJob, new Error("Temporary failure"));
expect(mockRedis.decr).not.toHaveBeenCalled();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/public-api/src/__tests__/mail.controller.test.js` around lines 223 -
227, The test currently invokes failedHandler only if it exists, allowing a
false pass when the handler was never registered; add an explicit assertion that
failedHandler is defined before calling it (e.g.,
expect(failedHandler).toBeDefined()) so the test fails if the handler wasn't
wired, then proceed to call failedHandler(mockJob, new Error("Temporary
failure")) and assert mockRedis.decr was not called; reference the failedHandler
symbol and the mockRedis.decr expectation to locate where to insert the
assertion.

Comment thread packages/common/src/queues/publicEmailQueue.js Outdated
Comment thread packages/common/src/queues/publicEmailQueue.js

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the public /api/mail/send endpoint in apps/public-api from synchronous Resend calls to an asynchronous BullMQ-backed delivery flow, adding a dedicated publicEmailQueue worker and adapting controller/tests accordingly.

Changes:

  • Added publicEmailQueue + initPublicEmailWorker() (BullMQ worker with rate limiting and BYOK key resolution inside the worker).
  • Updated /api/mail/send to enqueue jobs and return a job ID immediately instead of blocking on the provider call.
  • Updated public-api mail controller tests to mock BullMQ queueing and to verify quota refunds on terminal worker failures.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/common/src/queues/publicEmailQueue.js Introduces BullMQ queue/worker for public email delivery, including BYOK resolution and quota refund on terminal failure.
packages/common/src/index.js Exports the new queue and worker initializer from the common package.
apps/public-api/src/controllers/mail.controller.js Switches /api/mail/send to enqueue email jobs and return the BullMQ job ID.
apps/public-api/src/app.js Initializes the new public email worker during public-api startup (non-test env).
apps/public-api/src/tests/mail.controller.test.js Refactors tests to assert queueing behavior and to cover worker quota-refund logic.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +53 to +55
console.log(`[Queue] Processing public email to: ${finalPayload.to}`);

const { data, error } = await resend.emails.send(finalPayload);
Comment on lines +80 to +84
if (job && job.data && job.data.consumedQuotaKey) {
const maxAttempts = job.opts?.attempts || 1;
if (job.attemptsMade >= maxAttempts) {
await connection.decr(job.data.consumedQuotaKey).catch(() => {});
}
Comment on lines +308 to +313
const job = await publicEmailQueue.add("send-public-email", {
projectId,
payload,
usingByok,
consumedQuotaKey
});
@yash-pouranik

Copy link
Copy Markdown
Member Author

@copilot apply changes based on the comments in this thread

…queue

Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/091132d9-772a-45c6-9e9f-74b313afd713

Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com>
…r short local parts

Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/091132d9-772a-45c6-9e9f-74b313afd713

Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com>
@yash-pouranik
yash-pouranik temporarily deployed to feat/mail-bullmq - urBackend-frankfrut PR #157 May 8, 2026 08:25 — with Render Destroyed

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Applied all three review suggestions in commits 8b33857 and 5cb0cee:

  1. PII masking (publicEmailQueue.js:53-55): Replaced plaintext finalPayload.to in logs with a redact() function that masks the local part (keeping first 2 chars), handling both string and array recipients, and edge-casing local parts ≤ 2 chars.

  2. Safe quota refund (publicEmailQueue.js:80-84): Replaced bare DECR with a Lua script via connection.eval that only decrements when the key exists — prevents phantom negative keys with no TTL if the quota key expired or was evicted around month-end. Extracted as a module-level constant DECR_IF_EXISTS_SCRIPT.

  3. Retry + backoff (mail.controller.js:308-313): Added attempts: 3 and backoff: { type: 'exponential', delay: 5000 } to publicEmailQueue.add so transient failures retry before becoming terminal (the failed handler already compares attemptsMade vs maxAttempts correctly).

All 124 tests pass.

@yash-pouranik

Copy link
Copy Markdown
Member Author

@coderabbitai
please check this

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me fetch the review thread to understand the comments.


🏁 Script executed:

#!/bin/bash
# Fetch the specific review and its comments
gh api repos/geturbackend/urBackend/pulls/157/reviews --jq '.[] | {id, state, body, submitted_at}'
echo "---"
# Fetch review comments (inline)
gh api repos/geturbackend/urBackend/pulls/157/comments --jq '.[] | {id, path, line, body, pull_request_review_id}'

Length of output: 16649


Let me check the current state of the relevant files.


🏁 Script executed:

#!/bin/bash
# Read current state of the test file around lines 210-240
echo "=== mail.controller.test.js (lines 200-240) ==="
sed -n '200,240p' apps/public-api/src/__tests__/mail.controller.test.js

echo ""
echo "=== mail.controller.js (lines 270-330) ==="
sed -n '270,330p' apps/public-api/src/controllers/mail.controller.js

echo ""
echo "=== publicEmailQueue.js (full file) ==="
cat packages/common/src/queues/publicEmailQueue.js

Length of output: 7482


@yash-pouranik Here's the status of all review comments from the thread:

# File Issue Status
1 publicEmailQueue.js PII logging of finalPayload.to ✅ Fixed — redact() masking in place
2 publicEmailQueue.js DECR driving Redis negative ✅ Fixed — Lua DECR_IF_EXISTS_SCRIPT used
3 mail.controller.js No retry/backoff on publicEmailQueue.add ✅ Fixed — attempts: 3, backoff: exponential added
4 mail.controller.test.js if (failedHandler) allows false-pass ❌ Still open
5 mail.controller.js decrypt()/clientKey check still in controller ❌ Still open (nitpick)

Here's my plan to fix the two remaining items:

  • Fix if (failedHandler) false-pass in non-terminal failure test
  • Remove decrypt/clientKey resolution from controller; controller just checks if project.resendApiKey is a non-empty object to set usingByok

🏁 Script executed:

#!/bin/bash
# Check if decrypt is used anywhere else in the controller
grep -n 'decrypt' apps/public-api/src/controllers/mail.controller.js
# Check full import block at top of mail.controller.js
head -30 apps/public-api/src/controllers/mail.controller.js

Length of output: 1289


@yash-pouranik Here are the exact changes needed for the two remaining open items:


Fix 1 — apps/public-api/src/__tests__/mail.controller.test.js

Replace the if (failedHandler) guard so the test hard-fails when the handler was never registered:

-        if (failedHandler) {
-            await failedHandler(mockJob, new Error("Temporary failure"));
-        }
+        expect(failedHandler).toBeDefined();
+        await failedHandler(mockJob, new Error("Temporary failure"));

Fix 2 — apps/public-api/src/controllers/mail.controller.js

a) Remove decrypt from the import (it will no longer be used in this file):

-const { Project, MailTemplate, decrypt, redis, sendMailSchema, publicEmailQueue } = require("@urbackend/common");
+const { Project, MailTemplate, redis, sendMailSchema, publicEmailQueue } = require("@urbackend/common");

b) Replace the decrypt/clientKey block with a simple object-existence check. The worker already handles full BYOK resolution internally:

-    const encryptedByokKey =
-      project.resendApiKey && typeof project.resendApiKey === "object" && Object.keys(project.resendApiKey).length > 0
-        ? project.resendApiKey
-        : null;
-    const decryptedByokKey = encryptedByokKey ? decrypt(encryptedByokKey) : null;
-
-    const usingByok = typeof decryptedByokKey === "string" && decryptedByokKey.trim().length > 0;
-    const clientKey = usingByok
-      ? decryptedByokKey.trim()
-      : process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY;
-
-    if (!clientKey) {
-      return res.status(500).json({ success: false, data: {}, message: "Resend API key is not configured." });
-    }
+    const usingByok =
+      project.resendApiKey != null &&
+      typeof project.resendApiKey === "object" &&
+      Object.keys(project.resendApiKey).length > 0;

Why this is safe: The provider field in the response still works correctly — usingByok ? "byok" : "default" — and the worker independently fetches & decrypts the BYOK key using Project.findById + decrypt, so no credential handling is lost. The removed 500 guard is also safe to drop: if no Resend key is configured, the worker will throw and the job will retry/fail terminally (triggering the quota refund already in place).


The requested changes have been implemented and a pull request has been created: View PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants